{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Graph ConvNets in PyTorch\n",
    "\n",
    "PyTorch implementation of the NeurIPS'16 paper:\n",
    "Convolutional Neural Networks on Graphs with Fast Localized Spectral Filtering\n",
    "M Defferrard, X Bresson, P Vandergheynst\n",
    "Advances in Neural Information Processing Systems, 3844-3852, 2016\n",
    "[ArXiv preprint](https://arxiv.org/abs/1606.09375)\n",
    "\n",
    "Adapted from Xavier Bresson's repo: [spectral_graph_convnets](https://github.com/xbresson/spectral_graph_convnets) for [dataflowr](https://dataflowr.github.io/website/) by [Marc Lelarge](https://www.di.ens.fr/~lelarge/)\n",
    "\n",
    "## objective:\n",
    "\n",
    "The code provides a simple example of graph ConvNets for the MNIST classification task.\n",
    "The graph is a 8-nearest neighbor graph of a 2D grid.\n",
    "The signals on graph are the MNIST images vectorized as $28^2 \\times 1$ vectors."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "import torch.nn.functional as F\n",
    "import torch.nn as nn\n",
    "import collections\n",
    "import time\n",
    "import numpy as np\n",
    "import scipy\n",
    "from functools import partial\n",
    "import os\n",
    "\n",
    "if torch.cuda.is_available():\n",
    "    print('cuda available')\n",
    "    dtypeFloat = torch.cuda.FloatTensor\n",
    "    dtypeLong = torch.cuda.LongTensor\n",
    "    torch.cuda.manual_seed(1)\n",
    "else:\n",
    "    print('cuda not available')\n",
    "    dtypeFloat = torch.FloatTensor\n",
    "    dtypeLong = torch.LongTensor\n",
    "    torch.manual_seed(1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Download the data\n",
    "\n",
    "If you are running on colab, follow the instructions below.\n",
    "\n",
    "If you cloned the repo, go directly to the tempory hack."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Colab setting\n",
    "\n",
    "If you run this notebook on colab, please uncomment (and run) the following cells."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#!wget www.di.ens.fr/~lelarge/graphs.tar.gz"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#!tar -zxvf graphs.tar.gz"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#%cd graphs"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### temporary hack \n",
    "\n",
    "Unnecessary if running on colab (or if you already have MNIST), see this [issue](https://github.com/pytorch/vision/issues/3497)"
   ]
  },
  {
   "cell_type": "raw",
   "metadata": {},
   "source": [
    "!mkdir mnist\n",
    "%cd mnist\n",
    "!mkdir temp\n",
    "%cd temp\n",
    "!wget www.di.ens.fr/~lelarge/MNIST.tar.gz"
   ]
  },
  {
   "cell_type": "raw",
   "metadata": {},
   "source": [
    "!tar -zxvf MNIST.tar.gz"
   ]
  },
  {
   "cell_type": "raw",
   "metadata": {},
   "source": [
    "%cd ..\n",
    "%cd .."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Loading the data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def check_mnist_dataset_exists(path_data='./'):\n",
    "    flag_train_data = os.path.isfile(path_data + 'mnist/train_data.pt') \n",
    "    flag_train_label = os.path.isfile(path_data + 'mnist/train_label.pt') \n",
    "    flag_test_data = os.path.isfile(path_data + 'mnist/test_data.pt') \n",
    "    flag_test_label = os.path.isfile(path_data + 'mnist/test_label.pt') \n",
    "    if flag_train_data==False or flag_train_label==False or flag_test_data==False or flag_test_label==False:\n",
    "        print('MNIST dataset preprocessing...')\n",
    "        import torchvision\n",
    "        import torchvision.transforms as transforms\n",
    "        trainset = torchvision.datasets.MNIST(root=path_data + 'mnist/temp', train=True,\n",
    "                                                download=True, transform=transforms.ToTensor())\n",
    "        testset = torchvision.datasets.MNIST(root=path_data + 'mnist/temp', train=False,\n",
    "                                               download=True, transform=transforms.ToTensor())\n",
    "        train_data=torch.Tensor(60000,28,28)\n",
    "        train_label=torch.LongTensor(60000)\n",
    "        for idx , example in enumerate(trainset):\n",
    "            train_data[idx]=example[0].squeeze()\n",
    "            train_label[idx]=example[1]\n",
    "        torch.save(train_data,path_data + 'mnist/train_data.pt')\n",
    "        torch.save(train_label,path_data + 'mnist/train_label.pt')\n",
    "        test_data=torch.Tensor(10000,28,28)\n",
    "        test_label=torch.LongTensor(10000)\n",
    "        for idx , example in enumerate(testset):\n",
    "            test_data[idx]=example[0].squeeze()\n",
    "            test_label[idx]=example[1]\n",
    "        torch.save(test_data,path_data + 'mnist/test_data.pt')\n",
    "        torch.save(test_label,path_data + 'mnist/test_label.pt')\n",
    "    return path_data\n",
    "\n",
    "\n",
    "_ = check_mnist_dataset_exists()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#if you want to play with a small dataset (for cpu), uncomment.\n",
    "#nb_selected_train_data = 500\n",
    "#nb_selected_test_data = 100\n",
    "\n",
    "train_data=torch.load('mnist/train_data.pt').reshape(60000,784).numpy()\n",
    "#train_data = train_data[:nb_selected_train_data,:]\n",
    "print(train_data.shape)\n",
    "\n",
    "train_labels=torch.load('mnist/train_label.pt').numpy()\n",
    "#train_labels = train_labels[:nb_selected_train_data]\n",
    "print(train_labels.shape)\n",
    "\n",
    "test_data=torch.load('mnist/test_data.pt').reshape(10000,784).numpy()\n",
    "#test_data = test_data[:nb_selected_test_data,:]\n",
    "print(test_data.shape)\n",
    "\n",
    "test_labels=torch.load('mnist/test_label.pt').numpy()\n",
    "#test_labels = test_labels[:nb_selected_test_data]\n",
    "print(test_labels.shape)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from lib.grid_graph import grid_graph\n",
    "from lib.coarsening import coarsen, HEM, compute_perm, perm_adjacency\n",
    "from lib.coarsening import perm_data\n",
    "\n",
    "# Construct graph\n",
    "t_start = time.time()\n",
    "grid_side = 28\n",
    "number_edges = 8\n",
    "metric = 'euclidean'\n",
    "\n",
    "\n",
    "######## YOUR GRAPH ADJACENCY MATRIX HERE ########\n",
    "A = grid_graph(grid_side,number_edges,metric) # create graph of Euclidean grid\n",
    "######## YOUR GRAPH ADJACENCY MATRIX HERE ########"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def laplacian(W, normalized=True):\n",
    "    \"\"\"Return graph Laplacian\"\"\"\n",
    "    I = scipy.sparse.identity(W.shape[0], dtype=W.dtype)\n",
    "\n",
    "    #W += I\n",
    "    # Degree matrix.\n",
    "    d = W.sum(axis=0)\n",
    "\n",
    "    # Laplacian matrix.\n",
    "    if not normalized:\n",
    "        D = scipy.sparse.diags(d.A.squeeze(), 0)\n",
    "        L = D - W\n",
    "    else:\n",
    "        #\n",
    "        #\n",
    "        # your code here for normalized laplacian\n",
    "        #\n",
    "        #\n",
    "        pass\n",
    "\n",
    "    assert np.abs(L - L.T).mean() < 1e-8\n",
    "    assert type(L) is scipy.sparse.csr.csr_matrix\n",
    "    return L"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def rescale_L(L, lmax=2):\n",
    "    \"\"\"Rescale Laplacian eigenvalues to [-1,1]\"\"\"\n",
    "    M, M = L.shape\n",
    "    I = scipy.sparse.identity(M, format='csr', dtype=L.dtype)\n",
    "    L /= lmax * 2\n",
    "    L -= I\n",
    "    return L \n",
    "\n",
    "def lmax_L(L):\n",
    "    \"\"\"Compute largest Laplacian eigenvalue\"\"\"\n",
    "    return scipy.sparse.linalg.eigsh(L, k=1, which='LM', return_eigenvectors=False)[0]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Compute coarsened graphs\n",
    "coarsening_levels = 4\n",
    "\n",
    "L, perm = coarsen(A, coarsening_levels, partial(laplacian, normalized=False))\n",
    "\n",
    "# Compute max eigenvalue of graph Laplacians\n",
    "lmax = []\n",
    "for i in range(coarsening_levels):\n",
    "    lmax.append(lmax_L(L[i]))\n",
    "print('lmax: ' + str([lmax[i] for i in range(coarsening_levels)]))\n",
    "\n",
    "# Reindex nodes to satisfy a binary tree structure\n",
    "train_data = perm_data(train_data, perm)\n",
    "test_data = perm_data(test_data, perm)\n",
    "\n",
    "print('Execution time: {:.2f}s'.format(time.time() - t_start))\n",
    "del perm"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Here, we implemented the pooling layers and computed the list `L` containing the Laplacians of the graphs for each layer.\n",
    "\n",
    "<font color='red'>Question: what is the size of the various poolings?</font> "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Graph ConvNet LeNet5\n",
    "\n",
    "## Layers: CL32-MP4-CL64-MP4-FC512-FC10\n",
    "\n",
    "As described above, this network has 2 graph convolutional layers and two pooling layers with size 4.\n",
    "\n",
    "<font color='red'>Question: which graphs will you take in the list `L` for the graph convolutional layers?</font> \n",
    "\n",
    "In the code below, you will need to complete the `graph_conv_cheby` and the `graph_max_pool`.\n",
    "\n",
    "Hint: each time you permute dimensions, it is safe to add a `contiguous` like below:\n",
    "`x0 = x.permute(1,2,0).contiguous()` see [here](https://discuss.pytorch.org/t/call-contiguous-after-every-permute-call/13190/2) "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Graph_ConvNet_LeNet5(nn.Module):\n",
    "    \n",
    "    def __init__(self, net_parameters):\n",
    "        \n",
    "        print('Graph ConvNet: LeNet5')\n",
    "        \n",
    "        super(Graph_ConvNet_LeNet5, self).__init__()\n",
    "        \n",
    "        # parameters\n",
    "        D, CL1_F, CL1_K, CL2_F, CL2_K, FC1_F, FC2_F = net_parameters\n",
    "        FC1Fin = CL2_F*(D//16)\n",
    "        \n",
    "        # graph CL1\n",
    "        self.cl1 = nn.Linear(CL1_K, CL1_F) \n",
    "        self.init_layers(self.cl1, CL1_K, CL1_F)\n",
    "        self.CL1_K = CL1_K; self.CL1_F = CL1_F; \n",
    "        \n",
    "        # graph CL2\n",
    "        self.cl2 = nn.Linear(CL2_K*CL1_F, CL2_F) \n",
    "        self.init_layers(self.cl2, CL2_K*CL1_F, CL2_F)\n",
    "        self.CL2_K = CL2_K; self.CL2_F = CL2_F; \n",
    "\n",
    "        # FC1\n",
    "        self.fc1 = nn.Linear(FC1Fin, FC1_F) \n",
    "        self.init_layers(self.fc1, FC1Fin, FC1_F)\n",
    "        self.FC1Fin = FC1Fin\n",
    "        \n",
    "        # FC2\n",
    "        self.fc2 = nn.Linear(FC1_F, FC2_F)\n",
    "        self.init_layers(self.fc2, FC1_F, FC2_F)\n",
    "\n",
    "        # nb of parameters\n",
    "        nb_param = CL1_K* CL1_F + CL1_F          # CL1\n",
    "        nb_param += CL2_K* CL1_F* CL2_F + CL2_F  # CL2\n",
    "        nb_param += FC1Fin* FC1_F + FC1_F        # FC1\n",
    "        nb_param += FC1_F* FC2_F + FC2_F         # FC2\n",
    "        print('nb of parameters=',nb_param,'\\n')\n",
    "        \n",
    "        \n",
    "    def init_layers(self, W, Fin, Fout):\n",
    "\n",
    "        scale = np.sqrt( 2.0/ (Fin+Fout) )\n",
    "        W.weight.data.uniform_(-scale, scale)\n",
    "        W.bias.data.fill_(0.0)\n",
    "\n",
    "        return W\n",
    "        \n",
    "        \n",
    "    def graph_conv_cheby(self, x, cl, L, lmax, Fout, K):\n",
    "        # parameters\n",
    "        # B = batch size\n",
    "        # V = nb vertices\n",
    "        # Fin = nb input features\n",
    "        # Fout = nb output features\n",
    "        # K = Chebyshev order & support size\n",
    "        B, V, Fin = x.size(); B, V, Fin = int(B), int(V), int(Fin) \n",
    "\n",
    "        # rescale Laplacian\n",
    "        lmax = lmax_L(L)\n",
    "        L = rescale_L(L, lmax) \n",
    "        \n",
    "        # convert scipy sparse matric L to pytorch\n",
    "        L = L.tocoo()\n",
    "        indices = np.column_stack((L.row, L.col)).T \n",
    "        indices = indices.astype(np.int64)\n",
    "        indices = torch.from_numpy(indices)\n",
    "        indices = indices.type(torch.LongTensor)\n",
    "        L_data = L.data.astype(np.float32)\n",
    "        L_data = torch.from_numpy(L_data) \n",
    "        L_data = L_data.type(torch.FloatTensor)\n",
    "        L = torch.sparse.FloatTensor(indices, L_data, torch.Size(L.shape))\n",
    "        L.requires_grad_(False)\n",
    "        if torch.cuda.is_available():\n",
    "            L = L.cuda()\n",
    "        \n",
    "        # transform to Chebyshev basis\n",
    "        # \n",
    "        # your code here\n",
    "        # inputs\n",
    "        # x B x V x Fin\n",
    "        # cl linear layer Fin*K x Fout\n",
    "        # L Laplacian lmax max eigenvalue\n",
    "        # output should be B x V x Fout\n",
    "        #\n",
    "        \n",
    "        return x\n",
    "        \n",
    "        \n",
    "    # Max pooling of size p. Must be a power of 2.\n",
    "    def graph_max_pool(self, x, p): \n",
    "        # \n",
    "        # your code here\n",
    "        # input B x V x F output B x V/p x F\n",
    "        #\n",
    "        return x    \n",
    "        \n",
    "        \n",
    "    def forward(self, x, d, L, lmax):\n",
    "        # graph CL1\n",
    "        x = x.unsqueeze(2) # B x V x Fin=1  \n",
    "        x = self.graph_conv_cheby(x, self.cl1, L[0], lmax[0], self.CL1_F, self.CL1_K)\n",
    "        x = F.relu(x)\n",
    "        x = self.graph_max_pool(x, 4)\n",
    "        # graph CL2\n",
    "        x = self.graph_conv_cheby(x, self.cl2, L[2], lmax[2], self.CL2_F, self.CL2_K)\n",
    "        x = F.relu(x)\n",
    "        x = self.graph_max_pool(x, 4)\n",
    "        # FC1\n",
    "        x = x.view(-1, self.FC1Fin)\n",
    "        x = self.fc1(x)\n",
    "        x = F.relu(x)\n",
    "        x  = nn.Dropout(d)(x)\n",
    "        # FC2\n",
    "        x = self.fc2(x)\n",
    "        return x\n",
    "        \n",
    "        \n",
    "    def loss(self, y, y_target, l2_regularization):\n",
    "    \n",
    "        loss = nn.CrossEntropyLoss()(y,y_target)\n",
    "\n",
    "        l2_loss = 0.0\n",
    "        for param in self.parameters():\n",
    "            data = param* param\n",
    "            l2_loss += data.sum()\n",
    "           \n",
    "        loss += 0.5* l2_regularization* l2_loss\n",
    "            \n",
    "        return loss\n",
    "    \n",
    "    \n",
    "    def update(self, lr):\n",
    "                \n",
    "        update = torch.optim.SGD( self.parameters(), lr=lr, momentum=0.9 )\n",
    "        \n",
    "        return update\n",
    "        \n",
    "        \n",
    "    def update_learning_rate(self, optimizer, lr):\n",
    "   \n",
    "        for param_group in optimizer.param_groups:\n",
    "            param_group['lr'] = lr\n",
    "\n",
    "        return optimizer\n",
    "\n",
    "    \n",
    "    def evaluation(self, y_predicted, test_l):\n",
    "    \n",
    "        _, class_predicted = torch.max(y_predicted.data, 1)\n",
    "        return 100.0* (class_predicted == test_l).sum()/ y_predicted.size(0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Delete existing network if exists\n",
    "try:\n",
    "    del net\n",
    "    print('Delete existing network\\n')\n",
    "except NameError:\n",
    "    print('No existing network to delete\\n')\n",
    "\n",
    "# network parameters\n",
    "D = train_data.shape[1]\n",
    "CL1_F = 32\n",
    "CL1_K = 25\n",
    "CL2_F = 64\n",
    "CL2_K = 25\n",
    "FC1_F = 512\n",
    "FC2_F = 10\n",
    "net_parameters = [D, CL1_F, CL1_K, CL2_F, CL2_K, FC1_F, FC2_F]\n",
    "dropout_value = 0.5\n",
    "\n",
    "# instantiate the object net of the class \n",
    "net = Graph_ConvNet_LeNet5(net_parameters)\n",
    "if torch.cuda.is_available():\n",
    "    net.cuda()\n",
    "print(net)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Good time, to check your network is working..."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "train_x, train_y = train_data[:5,:], train_labels[:5]\n",
    "train_x =  torch.FloatTensor(train_x).type(dtypeFloat)\n",
    "train_y = train_y.astype(np.int64)\n",
    "train_y = torch.LongTensor(train_y).type(dtypeLong) \n",
    "            \n",
    "# Forward \n",
    "y = net(train_x, dropout_value, L, lmax)\n",
    "print(y.shape)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "# Weights\n",
    "L_net = list(net.parameters())\n",
    "\n",
    "# learning parameters\n",
    "learning_rate = 0.05\n",
    "l2_regularization = 5e-4 \n",
    "batch_size = 100\n",
    "num_epochs = 3\n",
    "train_size = train_data.shape[0]\n",
    "nb_iter = int(num_epochs * train_size) // batch_size\n",
    "print('num_epochs=',num_epochs,', train_size=',train_size,', nb_iter=',nb_iter)\n",
    "\n",
    "# Optimizer\n",
    "global_lr = learning_rate\n",
    "global_step = 0\n",
    "decay = 0.95\n",
    "decay_steps = train_size\n",
    "lr = learning_rate\n",
    "optimizer = net.update(lr) \n",
    "\n",
    "# loop over epochs\n",
    "indices = collections.deque()\n",
    "for epoch in range(num_epochs):  # loop over the dataset multiple times\n",
    "\n",
    "    # reshuffle \n",
    "    indices.extend(np.random.permutation(train_size)) # rand permutation\n",
    "    \n",
    "    # reset time\n",
    "    t_start = time.time()\n",
    "    \n",
    "    # extract batches\n",
    "    running_loss = 0.0\n",
    "    running_accuray = 0\n",
    "    running_total = 0\n",
    "    while len(indices) >= batch_size:\n",
    "        \n",
    "        # extract batches\n",
    "        batch_idx = [indices.popleft() for i in range(batch_size)]\n",
    "        train_x, train_y = train_data[batch_idx,:], train_labels[batch_idx]\n",
    "        train_x =  torch.FloatTensor(train_x).type(dtypeFloat)\n",
    "        train_y = train_y.astype(np.int64)\n",
    "        train_y = torch.LongTensor(train_y).type(dtypeLong) \n",
    "            \n",
    "        # Forward \n",
    "        y = net(train_x, dropout_value, L, lmax)\n",
    "        loss = net.loss(y,train_y,l2_regularization) \n",
    "        loss_train = loss.detach().item()\n",
    "        # Accuracy\n",
    "        acc_train = net.evaluation(y,train_y.data)\n",
    "        # backward\n",
    "        loss.backward()\n",
    "        # Update \n",
    "        global_step += batch_size # to update learning rate\n",
    "        optimizer.step()\n",
    "        optimizer.zero_grad()\n",
    "        # loss, accuracy\n",
    "        running_loss += loss_train\n",
    "        running_accuray += acc_train\n",
    "        running_total += 1\n",
    "        # print        \n",
    "        if not running_total%100: # print every x mini-batches\n",
    "            print('epoch= %d, i= %4d, loss(batch)= %.4f, accuray(batch)= %.2f' % (epoch+1, running_total, loss_train, acc_train))\n",
    "          \n",
    "    # print \n",
    "    t_stop = time.time() - t_start\n",
    "    print('epoch= %d, loss(train)= %.3f, accuracy(train)= %.3f, time= %.3f, lr= %.5f' % \n",
    "          (epoch+1, running_loss/running_total, running_accuray/running_total, t_stop, lr))\n",
    " \n",
    "    # update learning rate \n",
    "    lr = global_lr * pow( decay , float(global_step// decay_steps) )\n",
    "    optimizer = net.update_learning_rate(optimizer, lr)\n",
    "    \n",
    "    \n",
    "    # Test set\n",
    "    with torch.no_grad():\n",
    "        running_accuray_test = 0\n",
    "        running_total_test = 0\n",
    "        indices_test = collections.deque()\n",
    "        indices_test.extend(range(test_data.shape[0]))\n",
    "        t_start_test = time.time()\n",
    "        while len(indices_test) >= batch_size:\n",
    "            batch_idx_test = [indices_test.popleft() for i in range(batch_size)]\n",
    "            test_x, test_y = test_data[batch_idx_test,:], test_labels[batch_idx_test]\n",
    "            test_x = torch.FloatTensor(test_x).type(dtypeFloat)\n",
    "            y = net(test_x, 0.0, L, lmax) \n",
    "            test_y = test_y.astype(np.int64)\n",
    "            test_y = torch.LongTensor(test_y).type(dtypeLong)\n",
    "            acc_test = net.evaluation(y,test_y.data)\n",
    "            running_accuray_test += acc_test\n",
    "            running_total_test += 1\n",
    "        t_stop_test = time.time() - t_start_test\n",
    "        print('  accuracy(test) = %.3f %%, time= %.3f' % (running_accuray_test / running_total_test, t_stop_test))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<font color='red'>Question: by modifying the laplacian, is it possible to remove the rescaling step?</font> "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "dldiy",
   "language": "python",
   "name": "dldiy"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.8.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
